A todo list with React + Redux


Posted by Christy on 2022-04-12

This is how I made a todo list with React + Reduct

0. File structure

.
├── README.md
└── src
    ├── App.js
    ├── App.test.js
    ├── components
    │   ├── TodoCleanDone.js
    │   ├── TodoFilter.js
    │   ├── TodoInput.js
    │   └── TodoItem.js
    ├── index.js
    └──  redux
        ├── actionTypes.js
        ├── actions.js
        ├── reducers
        │   ├── index.js
        │   └── todo.js: CRUD logic in todo
        ├── selectors.js
        └── store.js

1. Basic set up

a. reducers folder > index.js

// reducers folder > index.js

import { combineReducers } from 'redux';
import todos from './todos';
import users from './users';

export default combineReducers({
  todoState: todos,
  users
});

b. todos.js

// todos.js

import { ADD_TODO, DELETE_TODO, CHECK_TODO } from '../actionTypes';

let todoId = 1;

const initialState = {
  todos: []
};

export default function todosReducer(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO: {
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: todoId++,
            name: action.payload.name,
            isDone: false
          }
        ]
      };
    }

    case DELETE_TODO: {
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload.id)
      };
    }

    case CHECK_TODO: {
      return {
        ...state,
        todos: state.todos.map((todo) => todo.id !== action.payload.id)
      };
    }
    default: {
      return state;
    }
  }
}

c. actions.js: action is an object

// actions.js

import { ADD_TODO, DELETE_TODO, CHECK_TODO, ADD_USER } from './actionTypes';

export function addTodo(name) {
  return {
    type: ADD_TODO,
    payload: {
      name
    }
  };
}

export function deleteTodo(id) {
  return {
    type: DELETE_TODO,
    payload: {
      id
    }
  };
}

export function checkTodo(id) {
  return {
    type: CHECK_TODO,
    payload: {
      id
    }
  };
}

export function addUser(name) {
  return {
    type: ADD_USER,
    payload: {
      name
    }
  };
}

d. actionTypes.js

// actionTypes.js

export const ADD_TODO = 'add_todo';
export const DELETE_TODO = 'delete_todo';
export const CHECK_TODO = 'check_todo';

e. selectors.js

// selectors.js

export const selectTodos = (store) => store.todoState.todos;

f. store.js

// store.js

import { createStore } from 'redux';
import rootReducer from './reducers';

export default createStore(rootReducer);

g. index.js

// index.js

import ReactDOM from 'react-dom';
import App from './App';
import { Provider } from 'react-redux';
import store from './redux/store';

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

h. App.js: create/delete/done/undone

// App.js

import styled from 'styled-components';
import { useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectTodos } from './redux/selectors';
import { addTodo, deleteTodo, checkTodo } from './redux/actions';

const TodoWrapper = styled.div`
  margin: 0 auto;
  text-align: center;
  padding: 30px;
`;

const Title = styled.div`
  color: #666;
  font-size: 30px;
  padding: 10px;
`;

const CreateToto = styled.div``;

const Input = styled.input`
  width: 300px;
  height: 24px;
`;

const AddButton = styled.button`
  font-size: 12px;
  margin-left: 10px;
  padding: 5px;
`;

const TodoList = styled.div`
  text-align: center;
  margin: 0 auto;
`;

const TodoItem = styled.div`
  display: flex;
  justify-content: space-between;
  text-align: center;
  margin: 5px auto;
  padding: 8px;
  width: 500px;
  border: 1px solid #ccc;
  border-radius: 3px;
`;

const ButtonWrapper = styled.div``;

const DeleteButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
`;

const CheckButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

export default function App() {
  const todos = useSelector(selectTodos);
  const dispatch = useDispatch();
  const [value, setValue] = useState('');

  const handleInputTodo = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  return (
    <TodoWrapper>
      <Title>Todo List</Title>
      <CreateToto>
        <Input value={value} onChange={handleInputTodo} />
        <AddButton
          onClick={() => {
            dispatch(addTodo(value));
            setValue('');
          }}>
          add todo
        </AddButton>
      </CreateToto>
      <TodoList>
        {todos.map((todo) => (
          <TodoItem $isDone key={todo.id} todo-id={todo.id}>
            {todo.name}
            <ButtonWrapper>
              <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
              <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>Done</CheckButton>
            </ButtonWrapper>
          </TodoItem>
        ))}
      </TodoList>
    </TodoWrapper>
  );
}

2. Let's start with todos.js、actions.js、actionTypes.js、App.js

Create/delete tood

a. export const ADD_TODO = 'add_todo';

b. Write a function to do something in action

import { ADD_TODO } from './actionTypes';

export function addTodo(name) {
  return {
    type: ADD_TODO,
    payload: {
      name
    }
  };
}

c. it's about logic in todos

import { ADD_TODO, DELETE_TODO } from '../actionTypes';

let todoId = 1;

const initialState = {
  todos: []
};

export default function todosReducer(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO: {
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: todoId++,
            name: action.payload.name,
            isDone: false
          }
        ]
      };
    }

    case DELETE_TODO: {
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload.id)
      };
    }
    default: {
      return state;
    }
  }
}

d. error log: react.development.js:1476 Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons...

Don't use useRef() in reducer function or it will show above error log

3. done/undone

a. export const CHECK_TODO = 'check_todo';

b. action

export function checkTodo(id) {
  return {
    type: CHECK_TODO,
    payload: {
      id
    }
  };
}

c. reducer

case CHECK_TODO: {
  return {
    ...state,
    todos: state.todos.map((todo) => {
      if (todo.id !== action.payload.id) return todo;
      return {
        ...todo,
        isDone: !todo.isDone
      };
    })
  };
}

d. App.js

  • force line break: word-wrap: break-word;
// App.js

// css
const TodoContent = styled.div`
  width: 100%;
  max-width: 240px;
  word-wrap: break-word;
  line-height: 26px;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

// logic
<TodoList>
  {todos.map((todo) => (
    <TodoItem key={todo.id}>
      <TodoContent $isDone={todo.isDone}>{todo.name}</TodoContent>
      <ButtonWrapper>
        <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
        <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>
          {todo.isDone ? 'Undone' : 'Done'}
        </CheckButton>
      </ButtonWrapper>
    </TodoItem>
  ))}
</TodoList>

4. Clear completed todo

It's similar with delete, keep todo.isDone !== true, the same step

a. export const CLEAR_COMPLETED_TODO = 'clear_completed_todo';

b. action

export function clearCompletedTodo(id) {
  return {
    type: CLEAR_COMPLETED_TODO,
    payload: {
      id
    }
  };
}

c. reducer

case CLEAR_COMPLETED_TODO: {
  return {
    ...state,
    todos: state.todos.filter((todo) => todo.isDone !== true)
  };
}

d. App.js

<ClearCompleted onClick={(todo) => dispatch(clearCompletedTodo(todo.isDone))}>
  Clear Completed
</ClearCompleted>

5. Filter todo(all, undone, done)

a. Filter todo with state

I didn't use dispatch() but with filter method, if the status is global then do it with dispatch(), otherwise do it with state.

// App.js Filter state

import styled from 'styled-components';
import { useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectTodos } from './redux/selectors';
import { addTodo, deleteTodo, checkTodo, clearCompletedTodo } from './redux/actions';

const TodoWrapper = styled.div`
  margin: 30px auto;
  text-align: center;
  padding: 30px;
  border: 1px solid #ccc;
  border-radius: 3px;
  max-width: 560px;
  width: 100%;
`;

const Title = styled.div`
  color: #666;
  font-size: 30px;
  padding: 10px;
`;

const CreateToto = styled.div``;

const Input = styled.input`
  width: 300px;
  height: 24px;
`;

const AddButton = styled.button`
  font-size: 12px;
  margin-left: 10px;
  padding: 5px;
  width: 60px;
`;

const SelectTodo = styled.div`
  padding: 20px;
`;

const TodoAllButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoActiveButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoCompletedButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoList = styled.div`
  text-align: center;
  margin: 0 auto;
`;

const TodoItem = styled.div`
  display: flex;
  justify-content: space-between;
  text-align: center;
  margin: 5px auto;
  padding: 8px;
  width: 100%;
  max-width: 400px;
  border: 1px solid #ccc;
  border-radius: 3px;
`;

const TodoContent = styled.div`
  width: 100%;
  max-width: 240px;
  word-wrap: break-word;
  line-height: 26px;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

const ButtonWrapper = styled.div``;

const DeleteButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const CheckButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const ClearCompleted = styled.button`
  height: 28px;
  width: 160px;
  margin-top: 10px;
`;

export default function App() {
  const todos = useSelector(selectTodos);
  const dispatch = useDispatch();
  const [value, setValue] = useState('');
  const [filter, setFilter] = useState('all');

  const handleInputTodo = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  const filterAll = () => {
    setFilter('all');
  };

  const filterDone = () => {
    setFilter('done');
  };

  const filterUndone = () => {
    setFilter('undone');
  };

  return (
    <TodoWrapper>
      <Title>Todo List</Title>
      <CreateToto>
        <Input value={value} onChange={handleInputTodo} />
        <AddButton
          onClick={() => {
            if (value) {
              dispatch(addTodo(value));
            }
            setValue('');
          }}>
          Add
        </AddButton>
      </CreateToto>
      <SelectTodo>
        <TodoAllButton onClick={filterAll}>All</TodoAllButton>
        <TodoActiveButton onClick={filterUndone}>Active</TodoActiveButton>
        <TodoCompletedButton onClick={filterDone}>Completed</TodoCompletedButton>
      </SelectTodo>
      <TodoList>
        {todos
          .filter((todo) => {
            if (filter === 'all') return todo;
            return filter === 'done' ? todo.isDone : !todo.isDone;
          })
          .map((todo) => (
            <TodoItem key={todo.id}>
              <TodoContent $isDone={todo.isDone}>{todo.name}</TodoContent>
              <ButtonWrapper>
                <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
                <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>
                  {todo.isDone ? 'Undone' : 'Done'}
                </CheckButton>
              </ButtonWrapper>
            </TodoItem>
          ))}
      </TodoList>
      <ClearCompleted onClick={(todo) => dispatch(clearCompletedTodo(todo.isDone))}>
        Clear Completed
      </ClearCompleted>
    </TodoWrapper>
  );
}

Optimization 1. Trim blank or unfilled in input box: if (!value.trim()) return

Optimization 2. Press enter after fill in input box

const handleKeyPress = useCallback((e) => {
  if (!value.trim()) return;
  if (e.key === 'Enter') {
    dispatch(addTodo(e.target.value));
    setValue('');
  }
});

b. Put filter as state in reducers to select todo

"one UI = one state", below method is different from what I thought at first beginning. I don't get that after stuck a whole day.


Version A:

a. actionTypes: export const SET_FILTER = 'set_filter';

b. actions:

export function filterTodo(filter) {
  return {
    type: SET_FILTER,
    payload: {
      filter
    }
  };
}

c. reducers folder > filters:

import { SET_FILTER } from '../actionTypes';

const initialFilter = {
  filters: 'All'
};

export default function filterReducer(state = initialFilter, action) {
  switch (action.type) {
    case SET_FILTER: {
      return action.payload.filter;
    }

    default: {
      return state;
    }
  }
}

d. reducers folder > index.js:

import { combineReducers } from 'redux';
import todos from './todos';
import users from './users';
import filters from './filters';

export default combineReducers({
  todoState: todos,
  filters,
  users
});

e. selectors:

export const selectFilters = (store) => store.filters.filters;

f. App.js

f.1 const filters = useSelector(selectFilters);

f.2 What I am trying to do here is put three conditions(all, done, undone) in filter store. The problem I met here is I don't know how to select todo and show, I think it's .filter(...).map(...)


Version B: Put three filter conditions in reducers

a. actionTypes:

export const FILTER_ALL = 'filter_all';
export const FILTER_DONE = 'filter_done';
export const FILTER_UNDONE = 'filter_undone';

b. actions:

export function filterAll() {
  return {
    type: FILTER_ALL
  };
}

export function filterDone() {
  return {
    type: FILTER_DONE
  };
}

export function filterUndone() {
  return {
    type: FILTER_UNDONE
  };
}

c. reducers > todos

case FILTER_ALL: {
  return {
    ...state,
    filter: 'all'
  };
}

case FILTER_DONE: {
  return {
    ...state,
    filter: 'done'
  };
}

case FILTER_UNDONE: {
  return {
    ...state,
    filter: 'undone'
  };
}

d. selectors:

export const selectFilters = (store) => store.todoState.filter;

e. App.js

import styled from 'styled-components';
import { useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectTodos, selectFilters } from './redux/selectors';
import {
  addTodo,
  deleteTodo,
  checkTodo,
  clearCompletedTodo,
  filterAll,
  filterDone,
  filterUndone
} from './redux/actions';

const TodoWrapper = styled.div`
  margin: 30px auto;
  text-align: center;
  padding: 30px;
  border: 1px solid #ccc;
  border-radius: 3px;
  max-width: 560px;
  width: 100%;
`;

const Title = styled.div`
  color: #666;
  font-size: 30px;
  padding: 10px;
`;

const CreateToto = styled.div``;

const Input = styled.input`
  width: 300px;
  height: 24px;
`;

const AddButton = styled.button`
  font-size: 12px;
  margin-left: 10px;
  padding: 5px;
  width: 60px;
`;

const FilterTodoWrapper = styled.div`
  padding: 20px;
`;

const TodoAllButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoActiveButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoCompletedButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoList = styled.div`
  text-align: center;
  margin: 0 auto;
`;

const TodoItem = styled.div`
  display: flex;
  justify-content: space-between;
  text-align: center;
  margin: 5px auto;
  padding: 8px;
  width: 100%;
  max-width: 400px;
  border: 1px solid #ccc;
  border-radius: 3px;
`;

const TodoContent = styled.div`
  width: 100%;
  max-width: 240px;
  word-wrap: break-word;
  line-height: 26px;
  text-align: left;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

const ButtonWrapper = styled.div``;

const DeleteButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const CheckButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const ClearCompleted = styled.button`
  height: 28px;
  width: 160px;
  margin-top: 10px;
`;

export default function App() {
  const todos = useSelector(selectTodos);
  const filters = useSelector(selectFilters);
  const dispatch = useDispatch();
  const [value, setValue] = useState('');

  const handleInputTodo = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  const handleKeyPress = (e) => {
    if (!value.trim()) return;
    if (e.key === 'Enter') {
      dispatch(addTodo(value));
      setValue('');
    }
  };

  return (
    <TodoWrapper>
      <Title>Todo List</Title>
      <CreateToto>
        <Input value={value} onChange={handleInputTodo} onKeyPress={handleKeyPress} />
        <AddButton
          onClick={() => {
            if (!value.trim()) return;
            dispatch(addTodo(value));
            setValue('');
          }}>
          Add
        </AddButton>
      </CreateToto>

      <FilterTodoWrapper>
        <ButtonWrapper>
          <TodoAllButton onClick={() => dispatch(filterAll('all'))}>All</TodoAllButton>
          <TodoActiveButton onClick={() => dispatch(filterUndone('undone'))}>
            Active
          </TodoActiveButton>
          <TodoCompletedButton onClick={() => dispatch(filterDone('done'))}>
            Completed
          </TodoCompletedButton>
        </ButtonWrapper>
      </FilterTodoWrapper>

      <TodoList>
        {todos
          .filter((todo) => {
            if (filters === 'all') return todo;
            if (filters === 'done') return todo.isDone;
            return !todo.isDone;
          })
          .map((todo) => (
            <TodoItem key={todo.id}>
              <TodoContent $isDone={todo.isDone}>{todo.content}</TodoContent>
              <ButtonWrapper>
                <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
                <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>
                  {todo.isDone ? 'Undone' : 'Done'}
                </CheckButton>
              </ButtonWrapper>
            </TodoItem>
          ))}
      </TodoList>
      <ClearCompleted onClick={(todo) => dispatch(clearCompletedTodo(todo.isDone))}>
        Clear Completed
      </ClearCompleted>
    </TodoWrapper>
  );
}

Note: I should use memo, useCallback in react as best as possible.










Related Posts

The introduction and difference between class component and function component in React

The introduction and difference between class component and function component in React

資結、Introduction to Algorithm Design

資結、Introduction to Algorithm Design

Go json and embedded struct

Go json and embedded struct


Comments